개요
만들 것
- 단일 테이블(
Todo)만 사용함. - 기본 CRUD만 집중
배울 것
- MySQL 연결 설정
- ORM 모델 정의
- 기본 CRUD 작업
- API 개발 및 테스트 과정
실습에 사용될 기술 스택
- 백엔드:
Flask,SQLAlchemy - 데이터베이스:
MySQL - 프론트엔드: 간단한 HTML
📁 프로젝트 구조
todo_app/
├── app.py # Flask 애플리케이션
├── models.py # ORM 모델
├── database.py # MySQL 연결 설정
├── config.py # 데이터베이스 설정
├── requirements.txt # 패키지 목록
└── templates/
└── index.html # 웹 UI1. MySQL 준비
MySQL에 접속해서 데이터베이스를 만들어주겠습니다.
mysql -uroot -p-- 데이터베이스 생성
CREATE DATABASE todo_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 사용자 생성 (선택사항)
CREATE USER 'todo_user'@'localhost' IDENTIFIED BY 'your_password';
-- 권한 부여
GRANT ALL PRIVILEGES ON todo_db.* TO 'todo_user'@'localhost';
-- 권한 적용
FLUSH PRIVILEGES;
-- 확인
SHOW DATABASES;
-- 나가기
EXIT;2. 패키지 설치
실습에 필요한 파이썬 패키지들을 설치할게요.
requirements.txt
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
PyMySQL==1.1.0
cryptography==41.0.7아래 명령어로 설치를 진행해주세요.
pip install -r requirements.txt3. 데이터베이스 설정
데이터베이스 연결을 위한 config를 정의하는 파일을 생성해주도록 하겠습니다.
config.py
"""
데이터베이스 설정 파일
MySQL 연결 정보를 관리합니다.
실제 운영에서는 환경변수나 .env 파일을 사용하세요!
"""
# ============================================
# MySQL 연결 정보
# ============================================
# MySQL 서버 정보
MYSQL_HOST = 'localhost' # MySQL 서버 주소
MYSQL_PORT = 3306 # MySQL 포트 (기본값: 3306)
MYSQL_USER = 'todo_user' # MySQL 사용자 이름
MYSQL_PASSWORD = 'your_password' # MySQL 비밀번호
MYSQL_DATABASE = 'todo_db' # 데이터베이스 이름
# ============================================
# SQLAlchemy 설정
# ============================================
class Config:
"""Flask 애플리케이션 설정"""
# MySQL 연결 URL 생성
# 형식: mysql+pymysql://사용자:비밀번호@호스트:포트/데이터베이스?charset=utf8mb4
SQLALCHEMY_DATABASE_URI = (
f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}'
f'@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}'
f'?charset=utf8mb4'
)
# SQLAlchemy 설정
SQLALCHEMY_TRACK_MODIFICATIONS = False # 성능 향상
SQLALCHEMY_ECHO = True # SQL 쿼리 로그 출력 (학습용)
# Flask 설정
SECRET_KEY = 'dev-secret-key-change-in-production'
DEBUG = True
- 실제 운영에서는 비밀번호를 코드에 직접 쓰지 마세요!
.env파일이나 환경변수를 사용하세요config.py를.gitignore에 추가하세요
database.py
"""
데이터베이스 연결 및 초기화
Flask-SQLAlchemy를 사용하여 MySQL과 연결합니다.
"""
from flask_sqlalchemy import SQLAlchemy
# ============================================
# SQLAlchemy 인스턴스 생성
# ============================================
# db 객체 생성
# 이 객체를 통해 모든 데이터베이스 작업을 수행합니다
db = SQLAlchemy()
def init_db(app):
"""
데이터베이스 초기화
Flask 앱과 SQLAlchemy를 연결하고 테이블을 생성합니다.
Args:
app: Flask 애플리케이션 인스턴스
"""
print("\n" + "=" * 60)
print("데이터베이스 초기화 중...")
print("=" * 60)
# Flask 앱과 SQLAlchemy 연결
db.init_app(app)
# 애플리케이션 컨텍스트 내에서 테이블 생성
with app.app_context():
# 모든 모델의 테이블 생성
# 이미 존재하는 테이블은 건드리지 않음
db.create_all()
print("\n✅ 데이터베이스 테이블 생성 완료!")
print("=" * 60 + "\n")
def drop_all_tables(app):
"""
모든 테이블 삭제 (개발용)
Args:
app: Flask 애플리케이션 인스턴스
"""
print("\n⚠️ 주의: 모든 테이블을 삭제합니다!")
with app.app_context():
db.drop_all()
print("✅ 모든 테이블이 삭제되었습니다.\n")- SQLAlchemy: 순수 SQLAlchemy (이전 프로젝트)
- Flask-SQLAlchemy: Flask와 통합된 버전
- Flask-SQLAlchemy가 Flask에서 더 편리함
4. ORM 모델 정의 (테이블 스키마 정의)
models.py
"""
ORM 모델 정의
Todo 테이블 하나만 사용하는 간단한 구조입니다.
"""
from database import db
from datetime import datetime
# ============================================
# Todo 모델 (할 일)
# ============================================
class Todo(db.Model):
"""
할 일 테이블
단일 테이블로 관계 없음!
각 할 일은 독립적으로 존재합니다.
"""
# 테이블 이름 지정
__tablename__ = 'todos'
# ===== 컬럼 정의 =====
# id: 기본 키 (Primary Key)
# Integer: 정수형
# primary_key=True: 기본 키로 설정
# autoincrement=True: 자동 증가 (MySQL의 AUTO_INCREMENT)
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
# title: 할 일 제목
# String(200): 최대 200자 문자열
# nullable=False: NULL 불가 (필수 입력)
title = db.Column(db.String(200), nullable=False)
# description: 할 일 설명 (선택사항)
# Text: 긴 텍스트
# nullable=True: NULL 가능 (기본값이므로 생략 가능)
description = db.Column(db.Text, nullable=True)
# completed: 완료 여부
# Boolean: True/False
# default=False: 기본값은 False (미완료)
completed = db.Column(db.Boolean, default=False, nullable=False)
# priority: 우선순위
# Integer: 1(낮음), 2(보통), 3(높음)
# default=2: 기본값은 2 (보통)
priority = db.Column(db.Integer, default=2, nullable=False)
# due_date: 마감일 (선택사항)
# DateTime: 날짜와 시간
due_date = db.Column(db.DateTime, nullable=True)
# created_at: 생성 시간
# default=datetime.utcnow: 생성 시 자동으로 현재 시간 설정
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# updated_at: 수정 시간
# onupdate=datetime.utcnow: 수정 시 자동으로 현재 시간으로 갱신
updated_at = db.Column(
db.DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False
)
def __repr__(self):
"""
객체를 문자열로 표현
print(todo) 했을 때 출력되는 내용
디버깅할 때 유용함
"""
return f"<Todo(id={self.id}, title='{self.title}', completed={self.completed})>"
def to_dict(self):
"""
객체를 딕셔너리로 변환
JSON 응답을 만들 때 사용
Returns:
dict: Todo 정보가 담긴 딕셔너리
"""
return {
'id': self.id,
'title': self.title,
'description': self.description,
'completed': self.completed,
'priority': self.priority,
'priority_text': self.get_priority_text(), # 우선순위 텍스트
'due_date': self.due_date.isoformat() if self.due_date else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def get_priority_text(self):
"""
우선순위를 텍스트로 변환
Returns:
str: 우선순위 텍스트 ('낮음', '보통', '높음')
"""
priority_map = {
1: '낮음',
2: '보통',
3: '높음'
}
return priority_map.get(self.priority, '보통')컬럼 타입 정리
| SQLAlchemy | MySQL | 설명 |
|---|---|---|
Integer |
INT |
정수 |
String(길이) |
VARCHAR(길이) |
가변 길이 문자열 |
Text |
TEXT |
긴 텍스트 |
Boolean |
TINYINT(1) |
True/False |
DateTime |
DATETIME |
날짜와 시간 |
Float |
FLOAT |
부동소수점 |
Numeric |
DECIMAL |
정밀한 소수 |
5. Flask API 구현
app.py
"""
Flask 애플리케이션 - Todo API
간단한 CRUD API를 제공합니다.
"""
from flask import Flask, jsonify, request, render_template
from config import Config
from database import db, init_db
from models import Todo
from datetime import datetime
# ============================================
# Flask 앱 생성 및 설정
# ============================================
app = Flask(__name__)
# 설정 로드
app.config.from_object(Config)
# 데이터베이스 초기화
init_db(app)
# ============================================
# 웹 페이지
# ============================================
@app.route('/')
def index():
"""
메인 페이지
모든 할 일을 표시합니다.
"""
# 모든 할 일 조회
# order_by(): 정렬
# - created_at.desc(): 생성 시간 내림차순 (최신이 위로)
todos = Todo.query.order_by(Todo.created_at.desc()).all()
# 통계 계산
total_count = len(todos)
completed_count = len([t for t in todos if t.completed])
pending_count = total_count - completed_count
return render_template(
'index.html',
todos=todos,
total_count=total_count,
completed_count=completed_count,
pending_count=pending_count
)
# ============================================
# API 엔드포인트
# ============================================
# ----- 조회 (READ) -----
@app.route('/api/todos', methods=['GET'])
def api_get_todos():
"""
모든 할 일 조회 API
GET /api/todos
Query Parameters:
- completed: true/false (완료 여부로 필터링)
- priority: 1/2/3 (우선순위로 필터링)
Returns:
JSON: 할 일 목록
"""
# 쿼리 시작
query = Todo.query
# 완료 여부로 필터링 (선택)
completed = request.args.get('completed')
if completed is not None:
# 'true' 문자열을 Boolean으로 변환
is_completed = completed.lower() == 'true'
query = query.filter(Todo.completed == is_completed)
# 우선순위로 필터링 (선택)
priority = request.args.get('priority', type=int)
if priority:
query = query.filter(Todo.priority == priority)
# 정렬 및 실행
todos = query.order_by(Todo.created_at.desc()).all()
# 딕셔너리 리스트로 변환
return jsonify([todo.to_dict() for todo in todos])
@app.route('/api/todos/<int:todo_id>', methods=['GET'])
def api_get_todo(todo_id):
"""
특정 할 일 조회 API
GET /api/todos/{todo_id}
Returns:
JSON: 할 일 정보
"""
# get_or_404(): ID로 조회, 없으면 자동으로 404 에러
todo = Todo.query.get_or_404(todo_id)
return jsonify(todo.to_dict())
# ----- 생성 (CREATE) -----
@app.route('/api/todos', methods=['POST'])
def api_create_todo():
"""
할 일 생성 API
POST /api/todos
Body: {
"title": "할 일 제목" (필수),
"description": "설명" (선택),
"priority": 1-3 (선택, 기본값: 2),
"due_date": "2024-12-31T23:59:59" (선택)
}
Returns:
JSON: 생성된 할 일 정보
"""
# 요청 데이터 가져오기
data = request.get_json()
# 유효성 검사
if not data:
return jsonify({'error': '데이터가 없습니다.'}), 400
if not data.get('title'):
return jsonify({'error': '제목은 필수입니다.'}), 400
# 우선순위 검증
priority = data.get('priority', 2)
if priority not in [1, 2, 3]:
return jsonify({'error': '우선순위는 1, 2, 3 중 하나여야 합니다.'}), 400
# 마감일 파싱 (선택)
due_date = None
if data.get('due_date'):
try:
# ISO 8601 형식 파싱
due_date = datetime.fromisoformat(data['due_date'])
except ValueError:
return jsonify({'error': '날짜 형식이 올바르지 않습니다. (ISO 8601 형식)'}), 400
try:
# Todo 객체 생성
todo = Todo(
title=data['title'],
description=data.get('description'),
priority=priority,
due_date=due_date
)
# 데이터베이스에 추가
db.session.add(todo)
# 커밋 (실제 저장)
db.session.commit()
print(f"✅ Todo 생성: {todo}")
# 생성된 Todo 반환
return jsonify(todo.to_dict()), 201
except Exception as e:
# 에러 발생 시 롤백
db.session.rollback()
print(f"❌ Todo 생성 실패: {e}")
return jsonify({'error': str(e)}), 500
# ----- 수정 (UPDATE) -----
@app.route('/api/todos/<int:todo_id>', methods=['PUT', 'PATCH'])
def api_update_todo(todo_id):
"""
할 일 수정 API
PUT/PATCH /api/todos/{todo_id}
Body: {
"title": "새 제목" (선택),
"description": "새 설명" (선택),
"completed": true (선택),
"priority": 3 (선택),
"due_date": "2024-12-31T23:59:59" (선택)
}
Returns:
JSON: 수정된 할 일 정보
"""
# Todo 조회
todo = Todo.query.get_or_404(todo_id)
# 요청 데이터 가져오기
data = request.get_json()
if not data:
return jsonify({'error': '데이터가 없습니다.'}), 400
try:
# 수정할 필드 업데이트
if 'title' in data:
todo.title = data['title']
if 'description' in data:
todo.description = data['description']
if 'completed' in data:
todo.completed = bool(data['completed'])
if 'priority' in data:
priority = data['priority']
if priority not in [1, 2, 3]:
return jsonify({'error': '우선순위는 1, 2, 3 중 하나여야 합니다.'}), 400
todo.priority = priority
if 'due_date' in data:
if data['due_date']:
try:
todo.due_date = datetime.fromisoformat(data['due_date'])
except ValueError:
return jsonify({'error': '날짜 형식이 올바르지 않습니다.'}), 400
else:
todo.due_date = None
# 커밋
db.session.commit()
print(f"✅ Todo 수정: {todo}")
return jsonify(todo.to_dict())
except Exception as e:
db.session.rollback()
print(f"❌ Todo 수정 실패: {e}")
return jsonify({'error': str(e)}), 500
# ----- 삭제 (DELETE) -----
@app.route('/api/todos/<int:todo_id>', methods=['DELETE'])
def api_delete_todo(todo_id):
"""
할 일 삭제 API
DELETE /api/todos/{todo_id}
Returns:
JSON: 성공 메시지
"""
# Todo 조회
todo = Todo.query.get_or_404(todo_id)
try:
# 삭제
db.session.delete(todo)
db.session.commit()
print(f"✅ Todo 삭제: ID {todo_id}")
return jsonify({'message': '할 일이 삭제되었습니다.'}), 200
except Exception as e:
db.session.rollback()
print(f"❌ Todo 삭제 실패: {e}")
return jsonify({'error': str(e)}), 500
# ----- 완료 토글 (편의 기능) -----
@app.route('/api/todos/<int:todo_id>/toggle', methods=['POST'])
def api_toggle_todo(todo_id):
"""
할 일 완료 상태 토글 API
POST /api/todos/{todo_id}/toggle
완료 ↔ 미완료 전환
Returns:
JSON: 수정된 할 일 정보
"""
todo = Todo.query.get_or_404(todo_id)
try:
# 완료 상태 반전
todo.completed = not todo.completed
db.session.commit()
status = "완료" if todo.completed else "미완료"
print(f"✅ Todo 상태 변경: ID {todo_id} → {status}")
return jsonify(todo.to_dict())
except Exception as e:
db.session.rollback()
print(f"❌ Todo 상태 변경 실패: {e}")
return jsonify({'error': str(e)}), 500
# ----- 통계 API -----
@app.route('/api/stats', methods=['GET'])
def api_stats():
"""
통계 API
GET /api/stats
Returns:
JSON: 통계 정보
"""
# 전체 개수
total = Todo.query.count()
# 완료된 개수
completed = Todo.query.filter(Todo.completed == True).count()
# 미완료 개수
pending = total - completed
# 우선순위별 개수
priority_counts = {
'high': Todo.query.filter(Todo.priority == 3).count(),
'medium': Todo.query.filter(Todo.priority == 2).count(),
'low': Todo.query.filter(Todo.priority == 1).count(),
}
return jsonify({
'total': total,
'completed': completed,
'pending': pending,
'priority_counts': priority_counts
})
# ============================================
# 애플리케이션 실행
# ============================================
if __name__ == '__main__':
print("\n" + "=" * 60)
print("✅ Todo API 서버 시작")
print("=" * 60)
print("\n🌐 웹 UI:")
print(" http://localhost:5000")
print("\n🔌 API 엔드포인트:")
print(" GET /api/todos # 모든 할 일 조회")
print(" GET /api/todos/{id} # 특정 할 일 조회")
print(" POST /api/todos # 할 일 생성")
print(" PUT /api/todos/{id} # 할 일 수정")
print(" DELETE /api/todos/{id} # 할 일 삭제")
print(" POST /api/todos/{id}/toggle # 완료 상태 토글")
print(" GET /api/stats # 통계")
print("\n" + "=" * 60 + "\n")
app.run(debug=True, host='0.0.0.0', port=5000)6. 웹 UI
templates/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 리스트</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f7fa;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
color: #2c3e50;
margin-bottom: 30px;
text-align: center;
}
.stats {
display: flex;
gap: 15px;
margin-bottom: 30px;
}
.stat-card {
flex: 1;
background: white;
padding: 15px;
border-radius: 8px;
text-align: center;
}
.stat-card h3 {
color: #7f8c8d;
font-size: 12px;
margin-bottom: 8px;
}
.stat-card .number {
font-size: 24px;
font-weight: bold;
color: #3498db;
}
.todo-form {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
color: #2c3e50;
font-weight: 500;
}
input, textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
textarea {
resize: vertical;
min-height: 60px;
}
button {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #2980b9;
}
.todo-list {
list-style: none;
}
.todo-item {
background: white;
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 15px;
}
.todo-item.completed {
opacity: 0.6;
}
.todo-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
.todo-content {
flex: 1;
}
.todo-title {
font-size: 16px;
font-weight: 500;
color: #2c3e50;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
}
.todo-description {
color: #7f8c8d;
font-size: 14px;
margin-top: 5px;
}
.priority-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
}
.priority-high {
background: #e74c3c;
color: white;
}
.priority-medium {
background: #f39c12;
color: white;
}
.priority-low {
background: #95a5a6;
color: white;
}
.delete-btn {
background: #e74c3c;
padding: 8px 15px;
}
.delete-btn:hover {
background: #c0392b;
}
.empty-message {
text-align: center;
color: #7f8c8d;
padding: 40px;
background: white;
border-radius: 8px;
}
</style>
</head>
<body>
<div class="container">
<h1>✅ Todo 리스트</h1>
<div class="stats">
<div class="stat-card">
<h3>전체</h3>
<div class="number">{{ total_count }}</div>
</div>
<div class="stat-card">
<h3>완료</h3>
<div class="number">{{ completed_count }}</div>
</div>
<div class="stat-card">
<h3>진행중</h3>
<div class="number">{{ pending_count }}</div>
</div>
</div>
<div class="todo-form">
<form id="todoForm">
<div class="form-group">
<label>할 일 *</label>
<input type="text" id="title" required placeholder="할 일을 입력하세요">
</div>
<div class="form-group">
<label>설명</label>
<textarea id="description" placeholder="상세 설명 (선택)"></textarea>
</div>
<div class="form-group">
<label>우선순위</label>
<select id="priority">
<option value="1">낮음</option>
<option value="2" selected>보통</option>
<option value="3">높음</option>
</select>
</div>
<button type="submit">추가</button>
</form>
</div>
<ul class="todo-list" id="todoList">
{% if todos %}
{% for todo in todos %}
<li class="todo-item {% if todo.completed %}completed{% endif %}" data-id="{{ todo.id }}">
<input type="checkbox" class="todo-checkbox"
{% if todo.completed %}checked{% endif %}
onchange="toggleTodo({{ todo.id }})">
<div class="todo-content">
<div class="todo-title">{{ todo.title }}</div>
{% if todo.description %}
<div class="todo-description">{{ todo.description }}</div>
{% endif %}
</div>
<span class="priority-badge priority-{{ 'high' if todo.priority == 3 else 'medium' if todo.priority == 2 else 'low' }}">
{{ todo.get_priority_text() }}
</span>
<button class="delete-btn" onclick="deleteTodo({{ todo.id }})">삭제</button>
</li>
{% endfor %}
{% else %}
<li class="empty-message">등록된 할 일이 없습니다.</li>
{% endif %}
</ul>
</div>
<script>
// 할 일 추가
document.getElementById('todoForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
title: document.getElementById('title').value,
description: document.getElementById('description').value,
priority: parseInt(document.getElementById('priority').value)
};
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) {
location.reload();
} else {
alert('추가 실패');
}
} catch (error) {
alert('에러 발생: ' + error);
}
});
// 완료 상태 토글
async function toggleTodo(id) {
try {
const response = await fetch(`/api/todos/${id}/toggle`, {
method: 'POST'
});
if (response.ok) {
location.reload();
}
} catch (error) {
alert('에러 발생: ' + error);
}
}
// 삭제
async function deleteTodo(id) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await fetch(`/api/todos/${id}`, {
method: 'DELETE'
});
if (response.ok) {
location.reload();
} else {
alert('삭제 실패');
}
} catch (error) {
alert('에러 발생: ' + error);
}
}
</script>
</body>
</html>7. API 테스트
테스트 1: 할 일 생성하기.
curl -X POST http://localhost:5000/api/todos \
-H "Content-Type: application/json" \
-d '{
"title": "ORM 학습하기",
"description": "SQLAlchemy로 MySQL 연결 실습",
"priority": 3
}'# 응답
{
"id": 1,
"title": "ORM 학습하기",
"description": "SQLAlchemy로 MySQL 연결 실습",
"completed": false,
"priority": 3,
"priority_text": "높음",
"due_date": null,
"created_at": "2024-11-22T10:30:00.123456",
"updated_at": "2024-11-22T10:30:00.123456"
}테스트 2: 모든 할 일 조회
curl http://localhost:5000/api/todos테스트 3: 특정 할 일 조회
curl http://localhost:5000/api/todos/1테스트 4: 할 일 수정
curl -X PUT http://localhost:5000/api/todos/1 \
-H "Content-Type: application/json" \
-d '{
"title": "ORM 마스터하기",
"completed": true
}'테스트 5: 완료된 할 일만 조회
curl "http://localhost:5000/api/todos?completed=true"테스트 6: 통계 조회
curl http://localhost:5000/api/stats# 응답
{
"total": 5,
"completed": 2,
"pending": 3,
"priority_counts": {
"high": 2,
"medium": 2,
"low": 1
}
}